iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0
ChatGPT & Codex

ChatGPT + Codex 打造高效能 AI 開發工作流系列 第 24

Day 24: Git 自動化工作流:自動生成 Commit Message 與 Release Notes

  • 分享至 

  • xImage
  •  

Day 24: Git 自動化工作流:自動生成 Commit Message 與 Release Notes (Git Commit Message and Release Notes)

本日核心價值 (Core Focus):git diff --staged 產生 Conventional Commits 訊息、用 git log vX..HEAD 彙整 Release Notes,全程經 subprocess 呼叫 Git;預設只印出結果,沒有 --yes 絕不 git commit,也絕不自動 push

概念說明與實戰情境 (Overview)

RAG 解決的是文件怎麼找;Git 解決的是變更怎麼被以後的人讀懂。Commit message 寫得亂,Day 17 的 CI 摘要、Changelog、on-call 追溯都會一起爛。讓模型讀 staged diff、輸出 feat / fix / docs 這類 Conventional Commits,比請它「寫得專業一點」可靠。Release Notes 則不該再讀一次整份 diff,而該讀已經審查過的 commit 標題,依類型分組。這兩件事都是生成工作流,不是把憑證交給 bot 去推遠端。預設行為必須是 dry-run:印出訊息,提交與推送留給人類。

關鍵操作與範例 (Implementation & Example)

1. Prompt:只看 staged diff,輸出單一標題

未 staged 的工作區雜訊不要進模型。訊息格式鎖定 Conventional Commits,避免模型寫成散文。主旨用祈使句、不超過 72 字元、句尾不加句點;scope 只取 diff 裡真實出現的模組名。需要內文時另開第二段,說明「為什麼改」而不是重述 diff。範例(人寫或模型寫都應長這樣):

  • feat(rag): refuse answers below similarity threshold
  • fix(api): retry OpenAI timeouts with jitter
  • docs(ironman): add day 23 chroma retrieval workflow
  • chore(ci): cache pip in github actions
SYSTEM:
You write a single Conventional Commit header from a staged git diff.
Format: type(scope): subject
Types: feat, fix, docs, refactor, test, chore, perf, ci.
Subject: imperative, <= 72 chars, no trailing period, Traditional Chinese or English matching the diff comments.
Do not invent files that are not in the diff.
If the diff is empty, reply EMPTY.
Return only the header line, no markdown.

USER:
Staged diff:

2. 腳本:subprocess 包 Git,--yes 才 commit,永不 push

from __future__ import annotations

import argparse
import os
import subprocess
from collections import defaultdict
from pathlib import Path

from openai import OpenAI

COMMIT_TYPES = ("feat", "fix", "docs", "refactor", "test", "chore", "perf", "ci")


def git(*args: str, cwd: Path) -> str:
    proc = subprocess.run(
        ["git", *args],
        cwd=cwd,
        check=True,
        capture_output=True,
        text=True,
        encoding="utf-8",
    )
    return proc.stdout


def staged_diff(repo: Path) -> str:
    return git("diff", "--staged", cwd=repo)


def propose_commit_message(diff: str) -> str:
    if not diff.strip():
        raise SystemExit("No staged changes. git add first; refusing to guess.")
    client = OpenAI()
    resp = client.chat.completions.create(
        model=os.environ.get("CHAT_MODEL", "gpt-4.1-mini"),
        temperature=0,
        messages=[
            {
                "role": "system",
                "content": (
                    "You write one Conventional Commit header from a staged git diff. "
                    "Format: type(scope): subject. Types: feat, fix, docs, refactor, test, chore, perf, ci. "
                    "Return only the header line."
                ),
            },
            {"role": "user", "content": f"Staged diff:\n{diff[:20000]}"},
        ],
    )
    line = (resp.choices[0].message.content or "").strip().splitlines()[0]
    if not any(line.startswith(f"{t}") or line.startswith(f"{t}(") for t in COMMIT_TYPES):
        raise SystemExit(f"Model output is not a conventional commit: {line!r}")
    return line


def maybe_commit(repo: Path, message: str, yes: bool) -> None:
    print(message)
    if not yes:
        print("Dry-run: pass --yes to run git commit. Push is never performed.")
        return
    subprocess.run(
        ["git", "commit", "-m", message],
        cwd=repo,
        check=True,
    )


def release_notes(repo: Path, since_ref: str) -> str:
    log = git("log", f"{since_ref}..HEAD", "--pretty=format:%s", cwd=repo)
    grouped: dict[str, list[str]] = defaultdict(list)
    other: list[str] = []
    for raw in log.splitlines():
        line = raw.strip()
        if not line:
            continue
        kind = next((t for t in COMMIT_TYPES if line.startswith(t)), None)
        if kind in {"feat", "fix"}:
            grouped[kind].append(line)
        else:
            other.append(line)
    parts = [f"## Changes since {since_ref}", ""]
    for title, key in (("Features", "feat"), ("Fixes", "fix")):
        parts.append(f"### {title}")
        items = grouped.get(key)
        if items:
            parts.extend(f"- {x}" for x in items)
        else:
            parts.append("- (none)")
        parts.append("")
    if other:
        parts.append("### Other")
        parts.extend(f"- {x}" for x in other)
    return "\n".join(parts).strip() + "\n"


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description="Propose commit message or release notes.")
    parser.add_argument("--repo", type=Path, default=Path.cwd())
    sub = parser.add_subparsers(dest="cmd", required=True)
    c = sub.add_parser("commit-msg")
    c.add_argument("--yes", action="store_true", help="Actually run git commit. Never pushes.")
    r = sub.add_parser("release-notes")
    r.add_argument("--since", required=True, help="Git ref, e.g. v1.4.0")
    args = parser.parse_args(argv)

    if args.cmd == "commit-msg":
        diff = staged_diff(args.repo)
        msg = propose_commit_message(diff)
        maybe_commit(args.repo, msg, yes=args.yes)
        return 0
    notes = release_notes(args.repo, args.since)
    print(notes)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

使用方式:

python git_ai.py commit-msg          # 只印訊息
python git_ai.py commit-msg --yes    # 才 git commit
python git_ai.py release-notes --since v1.4.0

第三個指令只讀 git log,不 bump tag、不打包、不 push。Release Notes 的輸入是已存在歷史,模型若要潤飾,也應只改分組與條列,不得新增 log 裡沒有的功能。把建議訊息交給模型之後,人仍要看一眼:scope 是否真的出現在 diff、type 是否誇大(把重構寫成 feat)、是否誤把 lockfile 或產生檔當主旨。通過才加 --yes。送進模型的 diff 可先過濾 package-lock.json*.min.js 這類噪音,否則 Token 成本會被鎖定檔吃掉(Day 22),訊息也會變成「update lockfile」。空的 staged diff 直接結束,不要請模型「猜今天可能改了什麼」。

3. Release Notes 分組後的輸出形狀

假設 git log v1.4.0..HEAD --pretty=format:%s 得到上列範例訊息,腳本應產出類似:

## Changes since v1.4.0

### Features
- feat(rag): refuse answers below similarity threshold

### Fixes
- fix(api): retry OpenAI timeouts with jitter

### Other
- docs(ironman): add day 23 chroma retrieval workflow
- chore(ci): cache pip in github actions

產品說明可再請模型把 feat / fix 翻成給使用者看的句子,但原始 header 要保留在附錄,方便對回 SHA。可選的 Git hook(prepare-commit-msg)只負責「把建議訊息印到編輯器」,仍不要在 hook 裡呼叫 git commitgit push。Hook 拿不到 --yes 這種人類確認時,預設應失敗或只填註解,避免 CI runner、GUI client 在背景自動提交。

範圍也要限縮:一次 staged 變更若同時包含 featfix,腳本應拒絕並要求拆 commit,而不是讓模型硬塞一個含糊的 chore。這比事後改寫歷史便宜,也讓 Release Notes 的分組統計有意義。團隊若已用 squash merge,Release Notes 應讀 PR 標題或 squash 後的單筆訊息,而不是 feature branch 上數十筆 WIP;來源選錯,分組再漂亮也只是把雜訊分類。訊息品質會直接餵給明天的排程摘要:n8n / Make 若每日彙整 feat / fix,標題含糊就會讓營運 digest 無法掃描。

這條工作流的價值不在「少打幾個字」,而在讓之後的自動化有穩定輸入。標題寫清楚,Day 17 的 CI 摘要、本篇的 Release Notes、明天的排程 digest 才能用同一套類型去過濾,不必再請模型猜「這次到底修了什麼」。相對地,把產生器接到 git add -A 或遠端推送,等於把不可信的模型輸出直接寫進歷史;因此確認鍵只有 --yes,而且確認的是「這行字可以進 repo」,不是「模型說可以就全部做完」。本機審查的最低清單是:沒有秘密檔、type 沒有誇大、scope 對得回 diff、遠端仍由人推送。若 diff 同時改測試與產品碼,主旨仍應寫行為變化(例如修逾時),測試檔不必寫進 scope,以免 Release Notes 變成「更新測試」而讀不出使用者影響。

注意事項與常見失敗 (Pitfalls)

  • 腳本預設就 git commit 甚至 git push。修法:預設 dry-run;--yes 只開放 commit;push 不出現在程式裡。
  • git diff(含未 staged)整份丟進模型。修法:只用 git diff --staged;過長截斷並在訊息中聲明「僅依據前 N 字元」。
  • 工作區還有秘密檔、.env。修法:commit 前用既有 hook / git status 人工確認;模型不得「幫忙 add 所有檔案」。
  • Release Notes 直接摘要 working tree,而不是 vX..HEAD。修法:只讀已提交歷史;未發布變更不算進該版說明。
  • 模型編造 scope 或未出現的檔案。修法:溫度 0、輸出單行、用 type 前綴校驗;不通過就失敗退出,不要自動重試到通過為止而放寬格式。
  • 在 CI 對每個 PR 用旗艦模型生成長篇詩意 Changelog。修法:commit message 用便宜模型(Day 22);Release Notes 可在 tag 時跑一次。

本日總結 (Takeaways)

  • Staged diff → 單行 Conventional Commit;範例應長得像 feat(scope): … / fix(scope): …。主旨對齊 diff,不要寫成散文。
  • 預設只列印建議訊息;真的要寫入歷史時才加 --yes 執行 git commit。推送永遠留在人工指令,腳本裡不要出現 git push
  • Release Notes 只讀 git log vX..HEAD 既有標題,按 feat / fix 分組;沒出現在 log 的功能不准編造。
  • Git 一律用 subprocess 呼叫,便於本機重跑與 code review;產生器是提案工具,不是擁有倉庫寫入權的 bot。

明日預告 (Next)

Commit 與 Release Notes 仍是「有人執行腳本才跑」。要把摘要、巡檢、通知變成每日營運,下一步接排程:Day 25 將做自動化排程工作流:結合 Make / n8n 與 OpenAI API 實現日常營運自動化。


上一篇
Day 23: 構建專屬知識庫工作流 (RAG Architecture):結合 Vector Database 與 Context Retrieval
系列文
ChatGPT + Codex 打造高效能 AI 開發工作流24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言